Skip to content

feat(examples): add todolist connect example exercising the new streaming API#331

Merged
ar3s3ru merged 19 commits intomainfrom
examples/add-todolist
Apr 22, 2026
Merged

feat(examples): add todolist connect example exercising the new streaming API#331
ar3s3ru merged 19 commits intomainfrom
examples/add-todolist

Conversation

@ar3s3ru
Copy link
Copy Markdown
Collaborator

@ar3s3ru ar3s3ru commented Apr 21, 2026

This PR resurrects the feature/add-todolist-example branch (archived at archive/feature/add-todolist-example) as a clean reapply on top of feat/event-stream-iterator (#330). The example serves as a real-world litmus test for the post-migration library API.

It's composed of:

  • TodoList aggregate with a child Item entity, built on aggregate.BaseRoot + aggregate.RecordThat.
  • A number of command handlers (CreateTodoList, AddTodoListItem) implementing command.Handler[Cmd].
  • One query handler (GetTodoList) implementing query.Handler[Q, R].
  • BDD tests via aggregate.Scenario, command.Scenario.
  • A Connect service over HTTP/2 (h2c) with gRPC health + reflection.

The command path loads the aggregate through EventSourcedRepository.Get, which internally streams events through the new message.Stream[event.Persisted] iterator. That's the litmus test.

Design choices (departures from the original branch)

  • Connect only. No google.api.http annotations, no googleapis dep. Connect already speaks gRPC + gRPC-Web + Connect-over-HTTP.
  • Commands return google.protobuf.Empty. Clients generate IDs (UUID) and pass them in the request. Idempotent on retry; no response-payload coupling.
  • connectrpc.com/{connect,grpchealth,grpcreflect} (not the deprecated bufbuild/connect-* module path).
  • In-memory store. Swap event.NewInMemoryStore() for postgres.NewEventStore(...) in main.go to get durability.
  • Nested go.mod with go.work to resolve inter-module dependencies.

Base automatically changed from feat/event-stream-iterator to main April 21, 2026 20:43
ar3s3ru added 18 commits April 21, 2026 22:44
Ten proto files following the 1-1-1 best practice (one service per file,
one message per file):

  - domain messages:  todo_list, todo_item
  - command requests: create_todo_list_request, add_todo_item_request,
                      mark_todo_item_as_done_request,
                      mark_todo_item_as_pending_request,
                      delete_todo_item_request
  - query request:    get_todo_list_request
  - query response:   get_todo_list_response
  - service:          todo_list_service

Design choices:

  - Connect-only. No google.api.http annotations, no googleapis dep.
  - Commands return google.protobuf.Empty; clients generate IDs.
  - Queries return dedicated response messages.

Lint config allows empty RPC responses (the rest of the rules match
internal/user/proto/buf.yaml).
Vendored output of `buf generate` against the proto contracts. Stubs
use the connectrpc.com/connect codegen (pinned in buf.gen.yaml):

  - one .pb.go per proto message file
  - one .connect.go for the full service

Committing generated code keeps `go run ./examples/todolist` usable
with just a Go toolchain, no buf dependency. Regenerate by running
`buf generate proto` from the example root.
TodoList aggregate root with:

  - domain events (WasCreated, ItemWasAdded, ItemMarkedAsDone,
    ItemMarkedAsPending, ItemWasDeleted);
  - Item child entity rehydrated via nested Apply;
  - Create factory + AddItem / MarkItemAsDone / MarkItemAsPending /
    DeleteItem methods, each emitting the corresponding event via
    aggregate.RecordThat;
  - repository.go with type aliases for aggregate.{Getter,Saver,Repository}.

Imports use the library's current layout (aggregate, event, version),
not the legacy core/ prefix.

Test uses aggregate.Scenario(typ) to cover the happy path end-to-end
(create list -> add item -> mark done -> delete).
Two command handlers:

  - CreateTodoListHandler  — creates a new TodoList via todolist.Create
                             and persists it through the repository.
  - AddTodoListItemHandler — fetches an existing TodoList, calls AddItem,
                             saves the new version.

Both are struct-shaped handlers with a clock function and a repository
dependency. Unit tests use command.Scenario[Cmd, Handler] to cover:

  - create: empty ID / empty title / empty owner / happy path;
  - add item: list not found / duplicate item / empty title / happy path.

Imports target the library's current package layout; no core/ prefix.
GetTodoListHandler returns a TodoList by ID via an aggregate.Getter.

Empty IDs short-circuit with todolist.ErrEmptyID, matching the domain's
validation contract so the Connect layer can map it to InvalidArgument.
internal/protoconv: convert TodoList domain objects to their Protobuf
  counterparts (one-way for now: proto <- domain).

internal/connect: Connect server implementation for TodoListService.

  - CreateTodoList and AddTodoItem wired to their command handlers;
  - GetTodoList wired to its query handler;
  - MarkAsDone / MarkAsPending / Delete return CodeUnimplemented
    (domain methods exist and are covered by the aggregate test;
    adding command handlers for them is out of scope for this litmus
    test and belongs in a follow-up).

Domain errors are mapped to Connect codes:
  empty-field errors    -> InvalidArgument
  item already exists   -> AlreadyExists
  item/aggregate missing -> NotFound
  everything else       -> Internal

Error chains are propagated verbatim via fmt.Errorf(%w). Fine for an
example; a production service would sanitize.
Starts a Connect HTTP server backed by an in-memory event.Store. Wires
one query handler and two command handlers through an EventSourcedRepository.

Notes:

  - gRPC health + gRPC reflection (v1 and v1alpha) are registered
    alongside the service handler.
  - h2c transport is used so the plain-HTTP example still speaks HTTP/2,
    which Connect over gRPC requires.
  - SIGINT / SIGTERM trigger graceful shutdown with a configurable
    ShutdownTimeout; misconfiguration is surfaced via envconfig errors.

go.mod is a nested module with `replace` pointing at the repo root,
so the example tracks the library's in-progress changes without waiting
for a release.
Drops the `examples$` path exclusion from both linter and formatter
blocks in .golangci.yaml so examples get the same lint treatment as
the library itself. Generated code under examples/todolist/gen/ stays
excluded by virtue of the `generated: lax` setting recognising the
"Code generated by ... DO NOT EDIT" header.

Small fixes needed to pass the full lint gate in the new example:

  - interface-implementation assertions marked //nolint:exhaustruct
    (they intentionally use zero values; same convention as the
    library's own event.Store and postgres.EventStore assertions);
  - http.Server zero-value fields marked //nolint:exhaustruct
    (stdlib struct with many optional fields);
  - run() in main.go marked //nolint:funlen — linear wire-up is
    easier to read as one function;
  - whitespace tweaks to satisfy wsl_v5 inside the goroutine.

Note: golangci-lint run from repo root does not descend into
examples/todolist/ because it is a nested module. Lint for the
example is run with --config ../../.golangci.yaml from its root.
examples/todolist/README.md walks through what the example demonstrates,
how to run it, and the deliberate design choices (Connect-only, commands
return Empty, one-message-per-proto, in-memory store).

Root README.md gains a small 'Examples' section pointing at the
todolist example.

Linking the example from the root helps discoverability for anyone
landing on the library's pkg.go.dev page or GitHub landing.
Folds the seven request/response protos back into todo_list_service.proto
alongside the service definition. Domain messages (todo_list, todo_item)
stay in their own files, preserving the boundary between transport
contracts and reusable domain types.

Rationale: for a service of this size (6 RPCs, all unary), colocating
requests and responses with the RPC that uses them reads better than
strict one-message-per-file splitting. A reader opening
todo_list_service.proto sees the full API contract without having to
cross-reference eight files.

Regenerated gen/ stubs accordingly; the connect stub is unchanged.
Ran `buf config migrate --module proto --buf-gen-yaml buf.gen.yaml`
from the example root, which:

  - moved proto/buf.yaml up to buf.yaml at the module root and bumped
    it to v2 schema (modules list now points at proto/);
  - rewrote buf.gen.yaml to v2 schema (plugin entries moved from
    `plugin:` to `remote:`; go_package_prefix moved under
    managed.override);
  - preserved all lint / breaking rule choices.

Also replaced the deprecated DEFAULT lint category with STANDARD per
buf's upgrade warning. `buf lint` now runs without any warnings.

README.md updated to reference the new `buf generate` command (no
longer needs an argument since the v2 config points at proto/).
Collapses internal/domain/todolist, internal/command, and internal/query
into a single internal/todolist package. All artifacts belonging to the
TodoList bounded context now live together; types use the package prefix
to stay readable at call sites:

  old                                           -> new
  domain.TodoList, .Item, .ID, .ItemID          -> todolist.*
  command.CreateTodoList / Handler              -> todolist.CreateCommand
                                                   todolist.CreateCommandHandler
  command.AddTodoListItem / Handler             -> todolist.AddItemCommand
                                                   todolist.AddItemCommandHandler
  query.GetTodoList / Handler                   -> todolist.GetQuery
                                                   todolist.GetQueryHandler

Command and query test files moved with their code (renames preserved by
git; blame stays intact).

Transport layers stay separate:
  - internal/connect keeps the Connect service implementation;
  - internal/protoconv keeps domain->proto conversions.

Both update their imports and references to the new names. Doc.go files
from the old command/query packages are gone — the top-level todolist.go
now carries the package doc comment.

README updated to reflect the 'package by domain' convention.
slog has been in the stdlib since Go 1.21 and is the idiomatic choice
for structured logging in Go today. The example has modest logging
needs (startup + shutdown messages), so pulling in go.uber.org/zap as
a third-party dep no longer earns its keep.

Behaviour changes:

  - logs go to stderr via slog.NewTextHandler at debug level;
  - slog.SetDefault makes the instance available to any transitive
    code that calls slog.Info / slog.Debug without wiring;
  - no more deferred Sync() or platform-specific stderr-sync quirks.

go.mod drops go.uber.org/zap and its transitive chain
(go.uber.org/multierr).
Extends the package-by-domain reorganisation to the transport and
conversion layers. internal/connect and internal/protoconv are gone;
their content now lives in the same internal/todolist package as the
aggregate, commands, and queries. Naming rides the package prefix:

  connect.TodoListServiceServer  -> todolist.ConnectServiceHandler
  connect.parseUUID              -> todolist.parseUUIDField  (unexported)
  connect.mapCommandError        -> todolist.mapCommandError (unexported)
  protoconv.FromTodoList         -> todolist.ToProto

The Connect service handler no longer needs a local import alias for
aggregate / command / query types since they live in the same package;
the file becomes markedly shorter. ToProto drops the trailing TodoList
suffix: within the todolist package it is unambiguous.

main.go loses the appconnect import alias; the server value is built
directly as todolist.ConnectServiceHandler.

README's 'package by domain' paragraph updated to reflect the fuller
scope (transport and conversion now folded in too).
Three new commands complete the TodoList command surface:

  - MarkItemAsDoneCommand / MarkItemAsDoneCommandHandler
  - MarkItemAsPendingCommand / MarkItemAsPendingCommandHandler
  - DeleteItemCommand / DeleteItemCommandHandler

Each handler loads the aggregate, dispatches the corresponding domain
method (TodoList.MarkItemAsDone / .MarkItemAsPending / .DeleteItem),
and persists the new version. No Clock dependency on these handlers
since the underlying events do not carry timestamps.

Each command ships with four scenario tests using command.Scenario[]():

  - list not found   -> aggregate.ErrRootNotFound
  - item not found   -> todolist.ErrItemNotFound
  - empty item ID    -> todolist.ErrEmptyItemID
  - happy path       -> asserts the expected Persisted event

Shared fixture constants (testListTitle, testListOwner) moved to a new
fixtures_test.go so the same strings aren't repeated across five test
files (the previous add_item_command_test.go also picks them up).
Replaces the three Unimplemented methods on ConnectServiceHandler with
real implementations:

  - MarkTodoItemAsDone dispatches MarkItemAsDoneCommand
  - MarkTodoItemAsPending dispatches MarkItemAsPendingCommand
  - DeleteTodoItem dispatches DeleteItemCommand

ConnectServiceHandler now carries six handler fields. Extracted a
parseListAndItemIDs helper to avoid duplicating the (todo_list_id,
todo_item_id) parsing dance across three otherwise identical methods.

Two small consequences of the struct growth:

  - method receivers switched from value to pointer (the struct is now
    112 bytes, past gocritic's hugeParam threshold);
  - interface-implementation assertion switched from the value-receiver
    form to (*ConnectServiceHandler)(nil), which also drops the
    //nolint:exhaustruct directive.

main.go builds a *ConnectServiceHandler and wires in all three new
handler dependencies alongside Create / AddItem / Get.

README: drop the bullet noting these RPCs were unimplemented.
Previously, `make go.lint` and `make go.test` only covered the root
module. `go test ./...` and `golangci-lint run` both stop at module
boundaries, so the todolist example — a nested module with its own
go.mod — was silently excluded from CI. A library change that broke
the example could land green.

Fix: the Makefile now discovers Go modules via `git ls-files '*go.mod'`
and runs each target in every module. Currently that means the root
library + `examples/todolist`; adding another example automatically
picks it up with no Makefile change needed.

Each nested module runs golangci-lint with an explicit
`--config $GOLANGCI_LINT_CONFIG` pointing at the root .golangci.yaml
(absolute path), so every module is linted with the same rule set
as the library.

Coverage: `go test -coverprofile=coverage.txt` writes a file per module
(one at repo root, one at examples/todolist/coverage.txt). The test
workflow now uploads both to Codecov; the list is inlined in the
workflow with a comment to keep it in sync with the Makefile loop.

Also added a `go.build` target that mirrors the same module iteration,
useful for local development and for any future CI step that wants a
cheaper "does it compile" gate.

README updated with a short 'CI coverage' section so the guarantee is
visible to future contributors.
Adds go.work at repo root declaring both modules (root library +
examples/todolist) as workspace members. The workspace delivers two
concrete benefits:

  - IDE / gopls sees the library and example as one unit, so
    go-to-definition, find-usages, and inline errors work across the
    boundary while editing.
  - examples/todolist/go.mod no longer needs the
    `replace github.com/get-eventually/go-eventually => ../..` hack;
    the workspace resolves the module locally.

It does NOT give us a single-invocation `go test ./...` across
modules: neither go nor golangci-lint span workspace members from a
single pattern (golang/vscode-go#2666). So the Makefile still iterates
— but the module list now comes from `go work edit -json` instead of
from a `git ls-files` heuristic, keeping go.work as the single
source of truth for 'what modules participate in this repo'.

Adding a new workspace member is now a single edit to go.work; the
Makefile (and therefore CI) picks it up automatically.

examples/todolist/go.mod's `require github.com/get-eventually/go-eventually`
line stays at v0.4.0 as a nominal floor. GOWORK=off would fall back to
resolving that version from the proxy — useful to know as a failure
mode if someone accidentally disables the workspace.

go.work.sum is committed, per Go 1.21+ convention.
@ar3s3ru ar3s3ru force-pushed the examples/add-todolist branch from 771cb72 to b01b14a Compare April 21, 2026 20:45
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 21, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 67.62%. Comparing base (a96b37a) to head (820482c).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #331      +/-   ##
==========================================
+ Coverage   62.65%   67.62%   +4.97%     
==========================================
  Files          39       37       -2     
  Lines        1727     1464     -263     
==========================================
- Hits         1082      990      -92     
+ Misses        583      416     -167     
+ Partials       62       58       -4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The todolist example's coverage file contains the example's own source
files, which were dragging the project coverage metric down without
meaningfully reflecting library quality.

Tell Codecov to ignore:

  - examples/** : the example's purpose is to be a litmus test for the
    library API; its own code coverage is not a project KPI. Library
    packages hit by the example's tests still count via the root
    coverage upload.
  - **/gen/** : generated protobuf/connect stubs have no meaningful
    coverage story.

Also sets a 1%% threshold on both project and patch status checks so
tiny refactors do not flag PRs red over immaterial coverage drift.
@ar3s3ru ar3s3ru merged commit 492525f into main Apr 22, 2026
10 checks passed
@ar3s3ru ar3s3ru deleted the examples/add-todolist branch April 22, 2026 06:11
@github-actions github-actions Bot mentioned this pull request Apr 22, 2026
ar3s3ru added a commit that referenced this pull request Apr 22, 2026
🤖 I have created a release *beep* *boop*
---


<details><summary>0.4.1</summary>

##
[0.4.1](v0.4.0...v0.4.1)
(2026-04-22)


### ⚠ BREAKING CHANGES

* replace channel-based event streaming with message.Stream iterator
([#330](#330))

### Features

* **examples:** add todolist connect example exercising the new
streaming API
([#331](#331))
([492525f](492525f))
* replace channel-based event streaming with message.Stream iterator
([#330](#330))
([a96b37a](a96b37a))


### Bug Fixes

* **release-please:** enable bump-*-pre-major feature flags
([f8456ab](f8456ab))
* **release:** drop package-name from release-please config
([#328](#328))
([6ae1f8e](6ae1f8e))
* rephrase docs
([1aa4425](1aa4425))


### Documentation

* **README:** add the How to Use section
([baddbb7](baddbb7))


### Miscellaneous

* release 0.4.1
([f34e71c](f34e71c))
</details>

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Danilo Cianfrone <danilocianfr@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant